For any suggestions or feedback regarding these notes,
please contact Pragy Agarwal
https://kshitijmishra23.github.io/lsm-tree-visualizer/
https://github.com/kshitijmishra23/lsm-tree-visualizer
Binary Search O(log n) is ultra fast compared to Linear Search O(n)
Consider a database with 1 trillion entries.
Binary Search takes O(log2 1 trillion) = 40 steps
Linear Search takes = 1 trillion steps
Binary Search is 25 billion times faster than Linear Search in this example!
Binary Searching the SSTable is expensive, because the SSTable size can be large.
Typical WAL size = 100MB
size SSTable on Level 1 <= 100MB (because it is compacted from the WAL file)
size SSTable on Level 2 <= 200MB (because it is compacted from 2 SSTables from Level 1)
size SSTable on Level 3 <= 400MB (because it is compacted from 2 SSTables from Level 2)
...
Consider an SSTable of size 100GB. Assume each entry (key, value, timestamp) on average is 100 bytes.
Number of entries in the SSTable = 100G bytes / 100 bytes = 1G = 1 billion
For a binary search, # iterations = log2 (109) = 9 * log2(10) ≈ 30 iterations
Each iteration on the disk is a random read. Each random read takes ~18ms
Total time for checking 1 SSTable = 30 * 18ms = 540 ms
If we have 10 SSTables, then each read potentially takes 5.4 seconds worst case.
Therefore, we need an index.
What if we get rid of the binary search O(log n) and instead use an in-memory (RAM) index to exactly pin-point the location of an entry in the SSTable O(1).
This will give us a 30x speedup (in the above example of SSTable with 1 billion entries)
So each SSTable lookup is now just 18 ms, and since you’ve 10 SSTables, your reads take 180ms total worst case (30x faster compared to 5.4 seconds).
Notice that this is not always the case — 90% of the reads are being served directly from the RAM thanks to the MemTable which acts as a read cache. Reading from the MemTable takes 0.1ms at worst.
Effective read latency is actually much better. Only 10% of you times you need to hit the disk, and in the worst case of that, you will hit 180ms of read latency
Effective read latency ~20ms on average.
Instead of binary searching the SSTable, what if we maintain a hashmap of {key: offset} in the RAM for each SSTable.
Given a key, we don't have to binary search the SSTable.
We just check the Index to find if the key is there in the SSTable. If the key is there in the index, the index will give us the exact offset within the file to read.
Reads: O(1) per SSTable. 30x speedup
However maintaining a full index in the RAM will take a lot of space.
Our SSTable has 1 billion entries. So the index will also have 1 billion entries.
For each entry we will need to store the key (say 20 bytes on avg), and the offset (8 bytes)
Total = 1 billion entries * 28 bytes / entry = 28GB for 1 SSTable.
For 10 SSTables, it will be 280GB.
That's too much RAM.
Sparse Index is built for each SSTable, and is stored in the RAM.
Sparse Index is built when the SSTable is created.
Because data is always read/written in blocks, we don't need to maintain an index of all the keys in the SSTables.
We can just maintain an index of the first key of each block.
Typical block size: 4KB
Assume each entry (key, value, timestamp) on average is 100 bytes.
Keys in each block 4K bytes / 100 bytes = 40 keys
Our sparse index will be smaller in size by a factor of 40.
Sparse Index will now only have 25 million entries (~100MB) for a 100GB SSTable.
Sparse Index is just a sorted list of keys (we only take the first key of each block) of the SSTable. Sparse Index is stored in the RAM.
Will this work? NO!
After this delete, if we try to read the value, we will end up searching the SSTables.
Effectively, this approach will not really delete the key, it will just undo the last key.
This will certainly get rid of the key.
Incredibly expensive.
Note that SSTables are immutable. If you delete data, since you can't shift all the other data, there will be gaps in the disk
Tombstone (aka Sentinel / Flag / Marker / Guard) is a special marker that indicates that the value has been deleted.
Any deletes happen via writes.
TOMBSTONE = “PZpaIBk8rbaIQVoUqGD2NS04qD3gONn0QH1Cm2DKBkoktwGuEt”
// it is practically impossible for your data to contain this exact random string by sheer chance.
void set(key, value) {
...
}
void delete(key) {
set(key, TOMBSTONE)
}
string _get(key) {
// check memtable
// check sstables
// ...
}
string get(key) {
value = _get(key)
if (value == TOMBSTONE) {
raise KeyNotFoundError!
}
return value
}
Note: Compaction can only delete Tombstones from the oldest SSTable (the one starting from SSTable 0)
If an SSTable is not the oldest, the tombstone entries must still be stored in the SSTable.
The following events can be triggered after any write:
Deletions happen by setting the value to TOMBSTONE
Bloom Filter also gets updated for each write
Note: during a read, if the value is found to be TOMBSTONE, we return a KeyNotFoundError
Yes. This is why, we've the following optimizations
If we try to get(key) for a key which was never inserted in the DB, then this leads to the slowest possible read - worst case for reads!
In a lot of databases, it is common to have to check if an entry was never inserted.
For example - signing up to a website - you have to create a username, this username must be distinct. The user will supply a unique username pragy1234 - we must ensure that this username doesn't exist in the DB already. This is an example of a read for a key that was never inserted.
BloomFilter is just a bit array.
void set(key, value) {
bloom_filter.insert(key)
// … proceed with the DB insertion
}
string get(key) {
if(! bloom_filter.contains(key))
raise KeyNotFound!
// if bloom filter says key found
// the DB might have the key, or might not have
the key
// proceed with normal DB check
}
On average, a properly tuned bloom filter uses only 10 bits per inserted key.